Skip to content

Conversation

@kdenney
Copy link
Contributor

@kdenney kdenney commented Oct 16, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-23713

📔 Objective

This PR modifies the behavior of the premium badge in the following ways when the feature flag pm-23713-premium-badge-opens-new-premium-upgrade-dialog is enabled:

  1. In Cloud-hosted Web, clicking on the badge now opens the same dialog used in the new navigation upgrade button, allowing the user to upgrade to premium or families immediately. After successfully upgrading, the vault is re-sync'd so that the badge no longer appears and the user can continue with what they were attempting to do.
  2. In Self-hosted Web, clicking on the badge navigates to the self-hosted premium subscription page where the user can look at options and if they select an option to upgrade, they'll be sent to the cloud-hosted web vault to upgrade.
  3. In Browser and Desktop, clicking on the badge now opens a new dialog with a prompt to upgrade and a button to upgrade.
    1. When pointed to a cloud-hosted server, the upgrade button will redirect the user to the cloud web vault's subscription page.
    2. When pointed to a self-hosted server, the button will redirect to the self-hosted subscription page.

To achieve this, several changes were made in locations where the premium badge was (or should have been) used, including:

  1. Ensuring that code locations that included the premium badge but also would have the parent container redirect to premium now instead use the PremiumUpgradePromptService in every case.
  2. This meant that I could also remove all usages of messaging service for redirecting to the premium page
  3. This also includes using the prompt service instead of the messaging service in the has-premium.guard

The new dialog PremiumUpgradeDialogComponent was created in libs/angular so that it could be used in both Browser and Desktop. This required moving the SubscriptionPricingService to libs/common and creating an abstraction for it and moving to libs required removing the ToastService usage from the pricing service and moving that logic to the components that use it instead.

The new dialog uses the pricing card component from libs/pricing but needed to customize some minor styles about how the card component renders. Two new customization properties were added to the card component to achieve this: disableCardPaddingTop and disableCardBorder.

A few other changes were made that require explanations and comments have been added to the files in this PR for those.

📸 Screenshots

Desktop dialog light mode:
desktop light

Desktop dialog dark mode:
desktop dark

Browser dialog light mode:
browser light

Browser dialog dark mode:
browser dark

Web vault reports page (did not use premium badge before, does now):
web-reports-premium

Video of the dialog being used in desktop:

desktop.video.mov

Video of the dialog being used in browser:

browser.video.mov

Video of the premium badge interaction in cloud-hosted web vault:

web-video.mov

Video of the premium badge going away after upgrading in the cloud-hosted web vault:

web-sync-after-upgrade.mov

Video of the premium badge redirecting to the subscription page in self-host web vault:

web.self.host.video.mov

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 Reviewer guidelines

  • 👍 (:+1:) or similar for great changes
  • 📝 (:memo:) or ℹ️ (:information_source:) for notes or general info
  • ❓ (:question:) for questions
  • 🤔 (:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • 🎨 (:art:) for suggestions / improvements
  • ❌ (:x:) or ⚠️ (:warning:) for more significant problems or concerns needing attention
  • 🌱 (:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt
  • ⛏ (:pick:) for minor or nitpick changes

@claude
Copy link

claude bot commented Oct 16, 2025

Claude finished @kdenney's task —— View job


Code Review Complete

I've conducted a comprehensive review of this PR. Overall, this is a well-architected feature with good test coverage and thoughtful implementation. The recent commits have addressed previous reviewer feedback well. Below are my findings:


Summary of Changes

This PR implements feature-flagged premium badge interactions across Web, Browser, and Desktop clients:

  • Cloud Web: Opens unified upgrade dialog for immediate Premium/Families upgrade
  • Self-hosted Web: Redirects to subscription page
  • Browser/Desktop: Opens new PremiumUpgradeDialogComponent with external link to upgrade

Key architectural changes:

  • New PremiumUpgradeDialogComponent in libs/angular (reusable across platforms)
  • Moved SubscriptionPricingService to libs/common with proper abstraction
  • Unified premium prompting via PremiumUpgradePromptService (removed messaging service pattern)
  • Made NotPremiumDirective reactive to state changes

Critical Issues

1. 🔴 Potential Null Pointer Exception in PremiumUpgradeDialogComponent

Location: libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts:60

map((tier) => this.mapPremiumTierToCardDetails(tier!)),

Issue: Using the non-null assertion operator (tier!) when the tier might not be found. If the Premium tier is missing from the API response, this will throw at runtime.

Recommendation:

map((tier) => {
  if (!tier) {
    throw new Error('Premium tier not found in pricing response');
  }
  return this.mapPremiumTierToCardDetails(tier);
}),

This would be caught by the existing error handler and properly logged.


2. ⚠️ Error Handling Could Be More User-Friendly

Location: libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts:61-70

Issue: When pricing fails to load, the dialog closes immediately with a generic error toast. Users might not understand what happened or how to retry.

Recommendation: Consider showing an error state within the dialog with a retry button, rather than closing immediately. This provides better UX and allows users to recover without re-triggering the entire flow.


3. ⚠️ Inconsistent Observable Pattern in has-premium.guard.ts

Location: apps/web/src/app/billing/guards/has-premium.guard.ts:39-41

return from(premiumUpgradePromptService.promptForPremium()).pipe(
  switchMap(() => of(userHasPremium)),
);

Issue: This creates an observable from a promise, waits for it, then returns the original userHasPremium value (which is false). This means the guard will always block navigation even after the prompt completes. The user sees the prompt but then navigation is still blocked.

Expected behavior: Either:

  1. Re-check premium status after the prompt completes, OR
  2. Return false to block navigation (current implicit behavior)

The current code seems intentional (block navigation after showing prompt), but the pattern is confusing. Consider adding a comment explaining why we return the original value.


Suggested Improvements

4. 🎨 Type Safety: Replace Optional Chaining with Explicit Check

Location: libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts:110

features: tier.passwordManager.features.map((f) => f.value),

Issue: If tier is passed through without validation, accessing passwordManager.features could fail.

Recommendation: Add explicit validation at the top of mapPremiumTierToCardDetails or use optional chaining with a fallback.


5. 🎨 Code Duplication in Platform Services

Locations:

  • apps/browser/src/vault/popup/services/browser-premium-upgrade-prompt.service.ts
  • apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts

Issue: Both services have nearly identical implementations with the same feature flag check and dialog opening logic.

Recommendation: Consider extracting the common logic to a base class or shared function in libs/angular. This would:

  • Reduce duplication
  • Ensure consistent behavior
  • Make future changes easier
Suggested approach
// libs/angular/src/billing/services/base-premium-upgrade-prompt.service.ts
export abstract class BasePremiumUpgradePromptService implements PremiumUpgradePromptService {
  constructor(
    protected configService: ConfigService,
    protected dialogService: DialogService,
  ) {}

  async promptForPremium() {
    const showNewDialog = await this.configService.getFeatureFlag(
      FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog,
    );

    if (showNewDialog) {
      PremiumUpgradeDialogComponent.open(this.dialogService);
    } else {
      await this.showLegacyPrompt();
    }
  }

  protected abstract showLegacyPrompt(): Promise<void>;
}

Then each platform service would only implement showLegacyPrompt().


6. 🎨 Test Coverage Gaps

Issue: Codecov reports 53.95% patch coverage with 99 lines missing coverage. Key areas:

  1. Story files (expected, these are for Storybook):

    • premium-upgrade-dialog.component.stories.ts (19 lines)
    • report-card.stories.ts (8 lines)
    • report-list.stories.ts (6 lines)
  2. Component logic (should be tested):

    • new-send-dropdown.component.ts (8 lines) - the sendFileClick() method
    • upgrade-payment.component.ts (14 lines) - error handling paths
    • has-premium.guard.ts (6 lines) - guard logic with premium prompt

Recommendation: Add tests for the non-story files, particularly:

  • new-send-dropdown.component.ts: Test both premium and non-premium paths
  • has-premium.guard.ts: Test guard behavior with/without premium

7. 🎨 Accessibility: Missing ARIA Labels

Location: libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.html:3-6

Issue: The dialog section has focus trap but lacks proper ARIA attributes for screen readers.

Recommendation: Add:

<section
  role="dialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-description"
  ...
>

And add id="dialog-title" to the h3 element.


8. 🎨 Hardcoded Magic Number

Location: libs/angular/src/billing/components/premium-upgrade-dialog/premium-upgrade-dialog.component.ts:102

amount: tier.passwordManager.annualPrice / 12,

Recommendation: Extract the magic number:

private static readonly MONTHS_PER_YEAR = 12;

// Then use:
amount: tier.passwordManager.annualPrice / PremiumUpgradeDialogComponent.MONTHS_PER_YEAR,

9. 💭 Consider Memory Leak in NotPremiumDirective

Location: libs/angular/src/billing/directives/not-premium.directive.ts:32-41

Issue: The directive subscribes in ngOnInit with takeUntilDestroyed(this.destroyRef), which is good. However, if account is null, it creates the view and returns early without any subscription.

Observation: This is actually fine - if there's no account, the badge should show (user is not premium). The early return is correct. Just noting for completeness.


10. 🌱 Future Technical Debt: i18n String Management

Location: Throughout the codebase

Issue: The PR adds ~24 new i18n strings across multiple platforms. Some strings are duplicated (e.g., "upgradeNow", "upgradeToPremium") across different contexts.

Recommendation: Consider a future refactor to:

  • Audit string usage for consolidation opportunities
  • Document which strings are feature-flag dependent
  • Ensure cleanup plan exists for when feature flag is removed

Good Practices Observed

  • ✅ Feature flag protection for gradual rollout
  • ✅ Comprehensive test coverage for core logic (dialog component has 100% coverage)
  • ✅ Proper error handling with logging and user feedback
  • ✅ Made directive reactive to state changes (fixes previous bug)
  • ✅ Used proper service abstraction pattern for SubscriptionPricingService
  • ✅ Follows Observable Data Services pattern (ADR-0003)
  • ✅ Uses takeUntilDestroyed() for subscription management
  • ✅ Proper use of standalone components
  • ✅ Recent commit properly closes dialog on error (addressing review feedback)
  • ✅ Removed unused message keys where appropriate
  • ✅ Added proper JSDocs to public methods

Action Items for Author

High Priority

  1. Fix the potential null pointer in PremiumUpgradeDialogComponent (line 60)
  2. Review guard behavior in has-premium.guard.ts - ensure it matches intended UX
  3. Add test coverage for new-send-dropdown.component.ts and has-premium.guard.ts

Medium Priority

  1. Consider better error UX in dialog (in-dialog error state vs immediate close)
  2. Add ARIA attributes to dialog for accessibility
  3. Extract hardcoded magic number (12 months)

Low Priority (Future Considerations)

  1. Consider refactoring duplicate code in platform services
  2. Plan for i18n string cleanup when feature flag is removed

Questions for Author

  1. Guard Behavior: In has-premium.guard.ts, is the intention to always block navigation after showing the premium prompt? The current implementation shows the prompt but then returns false. Should it re-check premium status after the prompt, or is blocking the correct behavior?

  2. Error Recovery: For the pricing service error in the dialog, was immediate close with toast intentional, or would an in-dialog error with retry be preferred?

  3. Test Coverage: The Codecov report shows some untested component logic. Are there plans to add tests for new-send-dropdown.component.ts and the guard?


Overall Assessment: This is solid work with good architecture and test coverage. The critical issues are relatively minor and mostly relate to edge case handling. Once the null pointer issue is addressed, this should be good to merge. The feature flag protection provides good safety for rollout.

@github-actions
Copy link
Contributor

github-actions bot commented Oct 16, 2025

Logo
Checkmarx One – Scan Summary & Detailsbcab1bdb-d9e6-4812-ad43-73dfd4ac0175

Great job! No new security vulnerabilities introduced in this pull request

@codecov
Copy link

codecov bot commented Oct 16, 2025

Codecov Report

❌ Patch coverage is 53.95349% with 99 lines in your changes missing coverage. Please review.
✅ Project coverage is 40.47%. Comparing base (b8921cb) to head (2e33459).
⚠️ Report is 49 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...dialog/premium-upgrade-dialog.component.stories.ts 0.00% 19 Missing ⚠️
...grade/upgrade-payment/upgrade-payment.component.ts 12.50% 14 Missing ⚠️
.../reports/shared/report-card/report-card.stories.ts 0.00% 8 Missing ⚠️
...c/new-send-dropdown/new-send-dropdown.component.ts 0.00% 8 Missing ⚠️
...ps/web/src/app/billing/guards/has-premium.guard.ts 0.00% 6 Missing ⚠️
.../reports/shared/report-list/report-list.stories.ts 0.00% 6 Missing ⚠️
libs/angular/src/tools/send/add-edit.component.ts 0.00% 6 Missing ⚠️
...ar/src/billing/directives/not-premium.directive.ts 28.57% 5 Missing ⚠️
...ult/services/web-premium-upgrade-prompt.service.ts 88.57% 2 Missing and 2 partials ⚠️
...ling/individual/premium/premium-vnext.component.ts 0.00% 3 Missing ⚠️
... and 11 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #16911      +/-   ##
==========================================
+ Coverage   40.46%   40.47%   +0.01%     
==========================================
  Files        3496     3506      +10     
  Lines       99960   100140     +180     
  Branches    14979    15005      +26     
==========================================
+ Hits        40452    40535      +83     
- Misses      57793    57886      +93     
- Partials     1715     1719       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kdenney kdenney requested a review from danielleflinn October 17, 2025 21:22
coroiu
coroiu previously approved these changes Oct 30, 2025
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => {
this.toastService.showToast({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an error occurs, the dialog shows a toast but remains open with no content. The user has to manually close a seemingly broken dialog. Is this the expected experience ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I added a line to close the dialog when this error occurs in 2abc88c

JimmyVo16
JimmyVo16 previously approved these changes Oct 30, 2025
@kdenney kdenney dismissed stale reviews from JimmyVo16 and coroiu via 2abc88c October 30, 2025 13:56
coroiu
coroiu previously approved these changes Oct 30, 2025
@kdenney kdenney merged commit e1e3966 into main Nov 3, 2025
174 of 177 checks passed
@kdenney kdenney deleted the billing/pm-23713/premium-badge-interaction branch November 3, 2025 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants